/* Copyright (c) 2001 - 2013 OpenPlans - www.openplans.org. All rights reserved.
 * This code is licensed under the GPL 2.0 license, available at the root
 * application directory.
 */
package org.geoserver;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NavigableSet;
import java.util.Properties;
import java.util.TreeSet;
import java.util.jar.Attributes;
import java.util.jar.Manifest;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

import org.apache.commons.io.IOUtils;
import org.geoserver.ManifestLoader.AboutModel.ManifestModel;
import org.geoserver.config.GeoServer;
import org.geoserver.platform.GeoServerResourceLoader;
import org.geotools.factory.GeoTools;
import org.geotools.util.logging.Logging;

/**
 * @author cancellieri carlo - GeoSolutions SAS
 * 
 */
public class ManifestLoader {

    private static final Logger LOGGER = Logging.getLogger(ManifestLoader.class.toString());

    // SETTIMGS
    public final static String RESOURCE_NAME_REGEX = "resourceNameRegex";

    public final static String RESOURCE_ATTRIBUTE_EXCLUSIONS = "resourceAttributeExclusions";

    public final static String VERSION_ATTRIBUTE_INCLUSIONS = "versionAttributeInclusions";

    public final static String PROPERTIES_FILE = "manifest.properties";

    // loaded settings form PROPERTIES_FILE
    private static Properties props;

    private static Pattern resourceNameRegex;

    private static String resourceAttributeExclusions[];

    private static String versionAttributeInclusions[];

    private static ClassLoader classLoader;

    /**
     * @throws Exception 
     * 
     */
    public ManifestLoader(GeoServerResourceLoader loader) throws Exception {

        classLoader = loader.getClassLoader();

        props = new Properties();

        // load from jar or classpath
        InputStream is = null;
        try {
            is = classLoader.getResourceAsStream("org/geoserver/" + PROPERTIES_FILE);
            if (is != null) {
                props.load(is);
            }
        } catch (IOException e) {
            LOGGER.log(Level.FINER, e.getMessage(), e);
        } finally {
            IOUtils.closeQuietly(is);
        }
        // override settings from datadir
        try {
            // datadir search
            File properties = loader.find(PROPERTIES_FILE);
            if (properties != null) {
                is = new FileInputStream(properties);
                props.load(is);
            }
        } catch (IOException e2) {
            LOGGER.log(Level.FINER, e2.getMessage(), e2);
        } finally {
            IOUtils.closeQuietly(is);
        }
        try {
            resourceNameRegex = Pattern.compile(props.getProperty(RESOURCE_NAME_REGEX)
                    + "!/META-INF/MANIFEST.MF");
        } catch (PatternSyntaxException e) {
            LOGGER.log(java.util.logging.Level.SEVERE, e.getLocalizedMessage(), e);
            throw e;
        }

        String ae = props.getProperty(RESOURCE_ATTRIBUTE_EXCLUSIONS);
        if (ae != null) {
            resourceAttributeExclusions = ae.split(",");
        } else {
            resourceAttributeExclusions = new String[0];
        }

        String ai = props.getProperty(VERSION_ATTRIBUTE_INCLUSIONS);
        if (ai != null) {
            versionAttributeInclusions = ai.split(",");
        } else {
            // defaults
            throw new Exception("Include attribute array cannot be null"); 
        }

    }

    /**
     * load an about model
     * 
     * @param loader
     * @return
     * @throws IllegalArgumentException if arguments are null
     */
    private static AboutModel getAboutModel(final ClassLoader loader)
            throws IllegalArgumentException {

        if (loader == null) {
            throw new IllegalArgumentException("Unable to run with null arguments");
        }

        final AboutModel model = new AboutModel();
        Map<String, Manifest> manifests = loadManifest(loader);
        Iterator<java.util.Map.Entry<String, Manifest>> it = manifests.entrySet().iterator();
        while (it.hasNext()) {
            java.util.Map.Entry<String, Manifest> entry = it.next();
            model.add(ManifestModel.parseManifest(trimName(entry.getKey()), entry.getValue(),
                    new ManifestModel.ExcludeAttributeFilter(resourceAttributeExclusions)));
        }
        return model;
    }

    private static Map<String, Manifest> loadManifest(final ClassLoader loader)
            throws IllegalArgumentException {

        if (loader == null) {
            throw new IllegalArgumentException("Unable to run with null arguments");
        }

        Map<String, Manifest> manifests = new HashMap<String, Manifest>();
        try {
            Enumeration<URL> resources = loader.getResources("META-INF/MANIFEST.MF");
            while (resources.hasMoreElements()) {
                InputStream is = null;
                try {
                    URL resource = resources.nextElement();

                    if (LOGGER.isLoggable(Level.FINE))
                        LOGGER.fine("Loading resources: " + resource.getFile());

                    is = resource.openStream();

                    manifests.put(resource.getPath(), new Manifest(is));

                } catch (IOException e) {
                    // handle
                    LOGGER.log(java.util.logging.Level.SEVERE,
                            "Error loading resources file: " + e.getLocalizedMessage(), e);
                } finally {
                    IOUtils.closeQuietly(is);
                }
            }
        } catch (IOException e) {
            LOGGER.log(java.util.logging.Level.SEVERE,
                    "Error loading resources file: " + e.getLocalizedMessage(), e);
        }

        return manifests;
    }

    private static String trimName(String path) {
        Matcher m = resourceNameRegex.matcher(path);
        if (m.matches())
            return m.group(1);
        else {
            String name = path.substring(0, path.length() - 22);
            return name.substring(name.lastIndexOf('/') + 1);
        }
    }

    /**
     * @return load the AboutModel of all the loaded resources
     */
    public static AboutModel getResources() {
        return getAboutModel(classLoader);
    }

    /**
     * @return dynamically built AboutModel of the geoserver's versions
     */
    public static AboutModel getVersions() {
        
        if (classLoader == null) {
            throw new IllegalArgumentException("Unable to run with null classLoader");
        }
        
        // load metadata
        Map<String, Manifest> manifests = ManifestLoader.loadManifest(classLoader);

        // start building the model
        AboutModel model = new AboutModel();
        try {
            // prepare the GeoServer metadata key
            String geoserverPath = GeoServer.class.getProtectionDomain().getCodeSource()
                    .getLocation().toURI().toString();
            geoserverPath = geoserverPath + "!/META-INF/MANIFEST.MF";

            Manifest manifest = manifests.get(geoserverPath);
            if (manifest != null) {
                model.add(ManifestModel.parseManifest("GeoServer", manifest,
                        new ManifestModel.IncludeAttributeFilter(versionAttributeInclusions)));
            }

        } catch (Exception e) {
            // be safe
            LOGGER.log(Level.FINE, "Error looking up geoserver package", e);
        }

        try {
            // prepare the GeoTools metadata key
            String path = GeoTools.class.getProtectionDomain().getCodeSource().getLocation()
                    .toURI().toString();
            path = path + "!/META-INF/MANIFEST.MF";

            Manifest manifest = manifests.get(path);
            if (manifest != null) {
                model.add(ManifestModel.parseManifest("GeoTools", manifest,
                        new ManifestModel.IncludeAttributeFilter(versionAttributeInclusions)));
            }
            // ManifestModel manifest = new ManifestModel("GeoTools");
            // manifest.putEntry("Version", GeoTools.getVersion().toString());
            // manifest.putEntry("Git-Revision", GeoTools.getBuildRevision().toString());
            // manifest.putEntry("Build-Timestamp", GeoTools.getBuildTimestamp());
            // model.add(manifest);
        } catch (Exception e) {
            // be safe
            LOGGER.log(Level.FINE, "Error looking up geoserver package", e);
        }

        try {
            // prepare the GeoWebCache metadata key
            String path = Class.forName("org.geowebcache.GeoWebCache").getProtectionDomain().getCodeSource().getLocation()
                    .toURI().toString();
            path = path + "!/META-INF/MANIFEST.MF";

            Manifest manifest = manifests.get(path);
            if (manifest != null) {
                model.add(ManifestModel.parseManifest("GeoWebCache", manifest,
                        new ManifestModel.IncludeAttributeFilter(versionAttributeInclusions)));
            }

            // Package p = GeoWebCache.class.getPackage();
            // if (p != null) {
            // ManifestModel manifest = new ManifestModel("GeoWebCache");
            // manifest.putEntry("Version",
            // p.getSpecificationVersion() != null ? p.getSpecificationVersion() : "");
            // manifest.putEntry("Git-Revision",
            // p.getImplementationVersion() != null ? p.getImplementationVersion() : "");
            // model.add(manifest);
            // }
        } catch (Exception e) {
            // be safe
            LOGGER.log(Level.FINE, "Error looking up org.geowebcache package", e);
        }
        return model;
    }

    /**
     * This is the model used to store resources from the class loader.
     * 
     * @author Cancellieri Carlo - GeoSolutions SAS
     */
    public static class AboutModel {

        private TreeSet<ManifestModel> manifests;

        /**
         * Type for the Model:<br>
         * {@link AboutModelType#VERSIONS} - means this model contains versions<br>
         * {@link AboutModelType#RESOURCES} - means this model contains resources
         */
        public enum AboutModelType {
            VERSIONS, RESOURCES;
        }

        public AboutModel() {
            manifests = new TreeSet<ManifestModel>(new ManifestModel.ManifestComparator());
        }

        /**
         * 
         * @param am
         * @throws IllegalArgumentException
         */
        public AboutModel(AboutModel am) throws IllegalArgumentException {
            if (am == null) {
                throw new IllegalArgumentException("Unable to initialize model with a null model");
            }
            manifests = new TreeSet<ManifestModel>(am.getManifests());
        }

        private AboutModel(NavigableSet<ManifestModel> manifests) throws IllegalArgumentException {
            if (manifests == null) {
                throw new IllegalArgumentException(
                        "Unable to initialize model with a null manifests tree");
            }
            this.manifests = new TreeSet<ManifestModel>(manifests);
        }

        /**
         * Filter resources from the used model generating a new one containing only resources having the name between from and to string.<br>
         * Note that objects are shared between models so changes to objects in the filtered model will also affect the current model.
         * 
         * @param from
         * @param to
         * @return the filtered model
         * @throws IllegalArgumentException if from or to are null
         */
        public AboutModel filterNameByRange(String from, String to) throws IllegalArgumentException {
            if (from == null || to == null) {
                throw new IllegalArgumentException("Unable to parse from or to are null");
            }
            return new AboutModel(getManifests().subSet(new ManifestModel(from), true,
                    new ManifestModel(to), true));
        }

        /**
         * Filter resources from the used model generating a new one containing only resources having the name matching the passed regular expression.<br>
         * Note that objects are shared between models so changes to objects in the filtered model will also affect the current model.
         * 
         * @param regex regular expression
         * @return a filtered model
         * @throws IllegalArgumentException if the regex is null
         */
        public AboutModel filterNameByRegex(String regex) throws IllegalArgumentException {
            if (regex == null) {
                throw new IllegalArgumentException("Unable to parse regex is null");
            }
            AboutModel am = new AboutModel(new TreeSet<ManifestModel>(
                    new ManifestModel.ManifestComparator()));
            Iterator<ManifestModel> it = manifests.iterator();
            while (it.hasNext()) {
                ManifestModel tModel = it.next();
                // filter over properties
                if (LOGGER.isLoggable(Level.FINE))
                    LOGGER.fine(tModel.getName());
                if (tModel.getName().matches(regex)) {
                    am.getManifests().add(tModel);
                }
            }
            return am;
        }

        /**
         * Filter resources from the used model generating a new one containing only resources having a key matching the passed string.<br>
         * Note that objects are shared between models so changes to objects in the filtered model will also affect the current model.
         * 
         * @param key the key to match
         * @return a filtered model
         * @throws IllegalArgumentException if the key is null
         */
        public AboutModel filterPropertyByKey(String key) throws IllegalArgumentException {
            if (key == null) {
                throw new IllegalArgumentException("Unable to parse key is null");
            }
            AboutModel am = new AboutModel();
            Iterator<ManifestModel> it = manifests.iterator();
            while (it.hasNext()) {
                ManifestModel tModel = it.next();
                if (filterPropertyByKey(tModel, key)) {
                    am.getManifests().add(tModel);
                }
            }
            return am;
        }

        /**
         * 
         * Filter resources from the used model generating a new one containing only resources having a property matching the passed string.<br>
         * Note that objects are shared between models so changes to objects in the filtered model will also affect the current model.
         * 
         * @param value the value of the property
         * @return the filtered model
         * @throws IllegalArgumentException if the value is null
         */
        public AboutModel filterPropertyByValue(final String value) throws IllegalArgumentException {
            if (value == null) {
                throw new IllegalArgumentException("Unable to parse: value is null");
            }
            AboutModel am = new AboutModel();
            Iterator<ManifestModel> it = manifests.iterator();
            while (it.hasNext()) {
                ManifestModel tModel = it.next();
                if (filterByPropertyValue(tModel, value)) {
                    am.getManifests().add(tModel);
                }
            }
            return am;
        }

        /**
         * 
         * Filter resources from the used model generating a new one containing only resources having a property key matching the passed key string
         * with a value matching the passed value string.<br>
         * Note that objects are shared between models so changes to objects in the filtered model will also affect the current model.
         * 
         * @param value the value of the property
         * @param key the name of the property
         * @return the filtered model
         * @throws IllegalArgumentException if the key or the value are null
         */
        public AboutModel filterPropertyByKeyValue(final String value, final String key)
                throws IllegalArgumentException {
            if (value == null && key == null) {
                throw new IllegalArgumentException("Unable to parse: property or key are null");
            }
            AboutModel am = new AboutModel();
            Iterator<ManifestModel> it = manifests.iterator();
            while (it.hasNext()) {
                ManifestModel tModel = it.next();
                if (filterPropertyByKeyValue(tModel, key, value)) {
                    am.getManifests().add(tModel);
                }
            }
            return am;
        }

        private boolean filterPropertyByKeyValue(final ManifestModel tModel, final String key,
                final String value) {
            // filter over properties
            for (Entry<String, String> e : tModel.getEntries().entrySet()) {
                if (e.getKey().matches(key) && e.getValue().matches(value)) {
                    // property mane matches
                    return true;
                }
            }
            return false;
        }

        private boolean filterPropertyByKey(final ManifestModel tModel, final String key) {
            // filter over properties
            for (Entry<String, String> e : tModel.getEntries().entrySet()) {
                if (e.getKey().matches(key)) {
                    // property mane matches
                    return true;
                }
            }
            return false;
        }

        private boolean filterByPropertyValue(final ManifestModel tModel, final String value) {
            // filter over values matches
            for (Entry<String, String> e : tModel.getEntries().entrySet()) {
                if (e.getValue().matches(value)) {
                    // property mane matches
                    return true;
                }
            }
            return false;
        }

        /**
         * Add a manifest file as resource with the given name
         * 
         * @param name
         * @param manifest
         * @return true if this set did not already contain the specified name
         */
        public boolean add(final String name, final Manifest manifest) {
            return manifests.add(ManifestModel.parseManifest(name, manifest,
                    new ManifestModel.ExcludeAttributeFilter(resourceAttributeExclusions)));
        }

        /**
         * Add a manifest file as resource
         * 
         * @param manifest
         * @return true if this set did not already contain the specified name
         */
        public boolean add(final ManifestModel manifest) {
            return manifests.add(manifest);
        }

        /**
         * remove the resource named 'name'
         * 
         * @param name (if null false is returned)
         * @return true if this set contained the specified element
         */
        public boolean remove(final String name) {
            if (name != null) {
                return manifests.remove(new ManifestModel(name));
            }
            return false;
        }

        public TreeSet<ManifestModel> getManifests() {
            return manifests;
        }

        /**
         * This is the model used to store one resource from the class loader.
         * 
         * @author Cancellieri Carlo - GeoSolutions SAS
         * 
         */
        public static class ManifestModel {

            private final String name;

            private final Map<String, String> entries;

            /**
             * A comparator useful to compare {@link ManifestModel}s by name
             */
            public static class ManifestComparator implements Comparator<ManifestModel> {

                @Override
                public int compare(ManifestModel o1, ManifestModel o2) {
                    return o1.getName().compareTo(o2.getName());
                }
            }

            /**
             * @return the name of the model
             */
            public String getName() {
                return name;
            }

            public Map<String, String> getEntries() {
                return entries;
            }

            public ManifestModel(final String name) {
                this.name = name;
                this.entries = new HashMap<String, String>();
            }

            public void putAllEntries(Map<String, String> entries) {
                this.entries.putAll(entries);
            }

            public void putEntry(final String name, final String value) {
                entries.put(name, value);
            }

            /**
             * A parser for {@link Manifest} bean which generates {@link ManifestModel}s
             * 
             * @param name the name to assign to the generated model
             * @param m the manifest bean to load
             * @return the generated model
             */
            private static ManifestModel parseManifest(final String name, final Manifest manifest,
                    final AttributesFilter<Map<String, String>> filter) {

                final ManifestModel m = new ManifestModel(name);

                // Main attributes
                try {
                    m.putAllEntries(filter.filter(manifest.getMainAttributes()));
                } catch (Exception e1) {
                    LOGGER.log(Level.FINER, e1.getMessage(), e1);
                }

                //
                Map<String, Attributes> attrs = manifest.getEntries();
                for (java.util.Map.Entry<String, Attributes> entry : attrs.entrySet()) {
                    try {
                        m.putAllEntries(filter.filter(entry.getValue()));
                    } catch (Exception e) {
                        LOGGER.log(Level.FINER, e.getMessage(), e);
                    }
                }

                return m;
            }

            /**
             * Interface used to define Attributes filter in {@link ManifestModel#parseManifest(String, Manifest, AttributesFilter)}
             * 
             * @author cancellieri
             * 
             * @param <T> the type return for the filter function
             */
            public interface AttributesFilter<T> {
                T filter(final Attributes at) throws Exception;
            }

            /**
             * INTERSECTION: create a map of properties from an attributes including only those matching the include array elements<br>
             * 
             * This implementation also supports attribute renaming using into the include array the pattern:<br>
             * include= { "attrName1:replaceName1", "attrName2:replaceName2", ...}<br>
             * 
             */
            public static class IncludeAttributeFilter implements
                    AttributesFilter<Map<String, String>> {
                private final String[] include;

                public IncludeAttributeFilter(final String[] include) {
                    super();
                    this.include = include;
                }

                @Override
                public Map<String, String> filter(final Attributes at) throws Exception {
                    return filterIncludingAttributes(at, include);
                }

                /**
                 * @param at
                 * @param include
                 * @return a map of properties
                 */
                private static Map<String, String> filterIncludingAttributes(final Attributes at,
                        String[] include) {
                    if (at == null)
                        throw new IllegalArgumentException("Null argument");

                    Map<String, String> ret = new HashMap<String, String>();

                    if (include == null) {
                        if (LOGGER.isLoggable(Level.FINE))
                            LOGGER.log(Level.FINE, "No includes: including all");
                        final Iterator<java.util.Map.Entry<Object, Object>> it = at.entrySet()
                                .iterator();
                        while (it.hasNext()) {
                            java.util.Map.Entry<Object, Object> entry = it.next();
                            String attrName = ((Attributes.Name) entry.getKey()).toString();
                            ret.put(attrName, entry.getValue().toString());
                        }
                    } else {
                        // for each attribute
                        final Iterator<java.util.Map.Entry<Object, Object>> it = at.entrySet()
                                .iterator();
                        while (it.hasNext()) {
                            java.util.Map.Entry<Object, Object> entry = it.next();
                            String attrName = ((Attributes.Name) entry.getKey()).toString();

                            // search into including array to filter over attributes
                            int i = 0;
                            while (i < include.length) {
                                // split key in original_key:replace_key
                                String key[] = include[i++].split(":");
                                if (attrName.matches(key[0]) == true) {
                                    ret.put(key.length > 1 ? key[1] : key[0], entry.getValue()
                                            .toString());
                                    break;
                                }
                            }
                        }
                    }
                    return ret;
                }

            }

            /**
             * COMPLEMENT: create a map of properties from an attributes excluding those matching the exclude array elements
             */
            public static class ExcludeAttributeFilter implements
                    AttributesFilter<Map<String, String>> {

                private final String[] exclude;

                public ExcludeAttributeFilter(final String[] exclude) {
                    super();
                    this.exclude = exclude;
                }

                @Override
                public Map<String, String> filter(final Attributes at) throws Exception {
                    return filterExcludingAttributes(at, exclude);
                }

                /**
                 * @param at the attribute to parse
                 * @param exclude the list of properties to exlude
                 * @return a map
                 */
                private static Map<String, String> filterExcludingAttributes(final Attributes at,
                        String[] exclude) {
                    if (at == null)
                        throw new IllegalArgumentException("Null arguments");
                    if (exclude == null) {
                        if (LOGGER.isLoggable(Level.FINE))
                            LOGGER.log(Level.FINE, "No exceptions");
                        exclude = new String[0];
                    }
                    Map<String, String> ret = new HashMap<String, String>();
                    // for each attribute
                    final Iterator<Object> it = at.keySet().iterator();
                    while (it.hasNext()) {
                        String attrName = ((Attributes.Name) it.next()).toString();
                        boolean skip = false;
                        // search into including array to filter over attributes
                        int i = 0;
                        while (i < exclude.length) {
                            if (attrName.matches(exclude[i++]) == true) {
                                skip = true;
                                break;
                            }
                        }
                        if (!skip)
                            ret.put(attrName, at.getValue(attrName));
                    }
                    return ret;
                }

            }

        }
    }
}
